Code
install.packages("dplyr")
install.packages("ggplot2")
install.packages("tidyr")
install.packages("flextable")
install.packages("purrr")
install.packages("checkdown")Martin Schweinberger
2026


This tutorial introduces the programming side of R: how to write code that makes decisions, repeats itself, and encapsulates reusable logic. These are the tools that transform R from an interactive calculator into a genuine programming environment — the tools you reach for when you want to automate a task, process many files at once, or build a custom analysis pipeline.
The tutorial uses linguistic examples throughout: text cleaning, corpus processing, token counting, and data wrangling tasks typical of language research. By the end, you will be able to write your own functions, process data in loops, and apply the same operation efficiently across many groups or files.
By the end of this tutorial you will be able to:
if/else, ifelse(), and dplyr::case_when() to make data-driven decisions in your codefor loops that iterate over vectors, lists, and files, with properly pre-allocated outputwhile loops for condition-driven iterationsapply(), lapply(), and apply() to replace common loop patternspurrr::map() and its typed variants as a modern alternative to the apply familytryCatch()Before working through this tutorial, please complete:
You should be comfortable with objects, vectors, data frames, and basic dplyr operations before continuing.
Martin Schweinberger. 2026. Working with R: Control Flow, Functions, and Programming. The Language Technology and Data Analysis Laboratory (LADAL), The University of Queensland, Australia. url: https://ladal.edu.au/tutorials/workingwithr/workingwithr.html (Version 3.1.1). doi: 10.5281/zenodo.19332999 .
Install required packages once:
Load packages and set options for this session:
library(dplyr) # data manipulation
library(ggplot2) # data visualisation
library(tidyr) # data reshaping
library(flextable) # formatted tables
library(purrr) # functional programming tools
library(checkdown) # interactive exercises
options(stringsAsFactors = FALSE)
options(scipen = 100)
options(max.print = 100)
set.seed(42)We work with a small simulated corpus throughout the tutorial — text samples with register and metadata:
corpus <- data.frame(
doc_id = paste0("doc", 1:12),
register = rep(c("Academic", "News", "Fiction"), each = 4),
text = c(
"The syntactic properties of embedded clauses remain poorly understood.",
"Phonological alternations in unstressed syllables exhibit considerable variation.",
"Discourse coherence is maintained through a variety of cohesive devices.",
"The morphological complexity of agglutinative languages poses theoretical challenges.",
"Scientists announced a major breakthrough in renewable energy storage yesterday.",
"Local authorities confirmed that road closures will affect the city centre this weekend.",
"The prime minister addressed parliament amid growing calls for electoral reform.",
"Unemployment figures fell sharply in the third quarter according to new statistics.",
"She had not expected the letter to arrive so soon, or to contain such news.",
"The old house creaked and groaned as the storm gathered strength outside.",
"He said nothing for a long time, watching the rain trace patterns on the glass.",
"By morning the fog had lifted and the valley lay green and still below them."
),
n_tokens = c(11, 10, 12, 11, 14, 16, 13, 14, 17, 15, 18, 16),
year = c(2019, 2020, 2021, 2022, 2019, 2020, 2021, 2022,
2019, 2020, 2021, 2022),
stringsAsFactors = FALSE
)What you will learn: How to make R take different actions depending on the data — the foundation of any decision-making code.
Key functions: if, else, else if, ifelse(), dplyr::case_when(), switch()
Why it matters: Real data is messy and varied. Conditional logic lets your code respond intelligently to what it finds rather than assuming all inputs look the same.
if / else statementsAn if statement runs a block of code only when a condition is TRUE. The optional else block runs when it is FALSE.
Corpus is large enough for analysis: 12 documents.
Chain multiple conditions with else if:
Average token count: 13.9 — Complexity: moderate
if requires a single TRUE or FALSE
The condition inside if() must evaluate to exactly one logical value. In R 4.2 and later, passing a vector of logicals is a hard error (in older versions it was a warning that used only the first element). Either way, it is never what you want.
Error: the condition has length > 1
Use any() or all() when you need to reduce a logical vector to a single value:
ifelse() — vectorised conditionalifelse() applies a condition to an entire vector and returns a vector of results — one value per element. This makes it ideal for creating or recoding columns inside dplyr::mutate():
dplyr::case_when() — multiple conditionsWhen you need more than two categories, case_when() is far cleaner than nested ifelse() calls. It works like a series of if/else if conditions evaluated top to bottom — the first matching condition wins:
Early Middle Recent
3 6 3
case_when() evaluation order
Conditions are tested top to bottom and the first match wins. Always put more specific conditions before less specific ones. The final TRUE ~ "value" acts as a catch-all default — it is good practice to always include one, because unmatched rows otherwise become NA.
switch() — selecting among named optionsswitch() is useful when a single variable can take one of several known values and you want to map each to a different result:
describe_register <- function(reg) {
switch(reg,
"Academic" = "Formal; high lexical density; passive constructions common",
"News" = "Neutral; inverted pyramid structure; quotations frequent",
"Fiction" = "Varied; narrative voice; dialogue and description",
"Unknown register" # default
)
}
describe_register("Academic")[1] "Formal; high lexical density; passive constructions common"
[1] "Unknown register"
Q1. What is the key difference between if and ifelse() in R?
Q2. In a case_when() call, what does the final TRUE ~ "Unknown" line do?
Q3. You want to add a column pos_class that is "function" when word is in c("the", "a", "of", "in") and "content" otherwise. Which code is correct?
for LoopsWhat you will learn: How to repeat a block of code for each element of a sequence or list.
Key concepts: Loop variable, iteration, pre-allocation, seq_along()
Why it matters: Loops automate repetitive tasks — processing multiple files, computing statistics per document, or building up results iteratively.
A for loop iterates over a sequence, executing its body once per element. The loop variable takes each element’s value in turn:
Academic : 4 documents
News : 4 documents
Fiction : 4 documents
When you need both the element and its position, loop over indices using seq_along(). This is safer than 1:length(x) because it handles zero-length vectors correctly:
Word 1: syntax (6 characters)
Word 2: morphology (10 characters)
Word 3: phonology (9 characters)
Word 4: pragmatics (10 characters)
Word 5: semantics (9 characters)
The most important loop performance rule: pre-allocate your output object before the loop, then fill it by index. Growing a vector by appending inside a loop forces R to copy the entire vector on every iteration — catastrophically slow for large inputs:
# Slow: growing inside the loop copies the vector on every iteration
results_slow <- c()
for (i in seq_along(words)) {
results_slow <- c(results_slow, nchar(words[i]))
}
# Fast: pre-allocate, then fill by index
results_fast <- integer(length(words))
for (i in seq_along(words)) {
results_fast[i] <- nchar(words[i])
}
results_fast[1] 6 10 9 10 9
Here we loop over registers, compute summary statistics for each, and collect results in a pre-allocated list:
registers <- unique(corpus$register)
summaries <- vector("list", length(registers))
names(summaries) <- registers
for (reg in registers) {
subset_df <- corpus[corpus$register == reg, ]
summaries[[reg]] <- data.frame(
register = reg,
n_docs = nrow(subset_df),
mean_tok = round(mean(subset_df$n_tokens), 1),
sd_tok = round(sd(subset_df$n_tokens), 2),
min_tok = min(subset_df$n_tokens),
max_tok = max(subset_df$n_tokens)
)
}
do.call(rbind, summaries) |>
flextable() |>
flextable::set_table_properties(width = .85, layout = "autofit") |>
flextable::theme_zebra() |>
flextable::fontsize(size = 12) |>
flextable::fontsize(size = 12, part = "header") |>
flextable::align_text_col(align = "center") |>
flextable::set_caption(caption = "Token statistics per register computed with a for loop.") |>
flextable::border_outer()register | n_docs | mean_tok | sd_tok | min_tok | max_tok |
|---|---|---|---|---|---|
Academic | 4 | 11.0 | 0.82 | 10 | 12 |
News | 4 | 14.2 | 1.26 | 13 | 16 |
Fiction | 4 | 16.5 | 1.29 | 15 | 18 |
One of the most practical uses of for loops in corpus linguistics is processing many text files in a directory:
txt_files <- list.files(path = "data/corpus/",
pattern = "\\.txt$",
full.names = TRUE)
results <- data.frame(
filename = character(length(txt_files)),
n_chars = integer(length(txt_files)),
n_lines = integer(length(txt_files)),
stringsAsFactors = FALSE
)
for (i in seq_along(txt_files)) {
text <- readLines(txt_files[i], warn = FALSE)
results$filename[i] <- basename(txt_files[i])
results$n_chars[i] <- sum(nchar(text))
results$n_lines[i] <- length(text)
}
head(results)break and nextTwo special keywords control loop flow. break exits the loop immediately; next skips to the next iteration:
Long documents only:
doc5 - 14 tokens
doc6 - 16 tokens
doc7 - 13 tokens
doc8 - 14 tokens
doc9 - 17 tokens
doc10 - 15 tokens
doc11 - 18 tokens
doc12 - 16 tokens
First Academic document:
doc1 : The syntactic properties of embedded clauses remai ...
for loopsLoops can be nested — the inner loop runs completely for each iteration of the outer loop:
Documents per register x era:
Academic x Early : 1
Academic x Middle : 2
Academic x Recent : 1
News x Early : 1
News x Middle : 2
News x Recent : 1
Fiction x Early : 1
Fiction x Middle : 2
Fiction x Recent : 1
Before writing a loop, ask: does a vectorised function or dplyr verb already do this? Vectorised operations in R are implemented in C and run far faster than R-level loops.
[1] 70 81 72 85 80 88 80 83 75 73 79 76
# A tibble: 3 × 2
register mean_tok
<chr> <dbl>
1 Academic 11
2 Fiction 16.5
3 News 14.2
Loops shine when each iteration depends on the result of the previous one, when you are reading or writing files, or when no vectorised alternative exists.
for Loops
Q1. Why should you pre-allocate your output vector before a for loop rather than growing it with c() inside the loop?
Q2. What does next do inside a for loop?
Q3. Why is seq_along(x) preferred over 1:length(x) when looping over a vector x?
while LoopsWhat you will learn: How to write loops that run until a condition changes rather than for a fixed number of iterations.
Key concepts: Loop condition, infinite loops, break as a safety exit
When to use: Convergence algorithms, reading data streams, retrying failed operations
A while loop runs its body as long as its condition remains TRUE. Use it when the number of iterations is not known in advance.
Reached 58 tokens after 5 documents.
Here we simulate reading tokens from a stream until we hit a sentence boundary:
tokens <- c("The", "quick", "brown", "fox", "jumps", ".", "Over", "the", "lazy")
sentence <- character(0)
j <- 0
while (j < length(tokens)) {
j <- j + 1
current <- tokens[j]
sentence <- c(sentence, current)
if (grepl("\\.$", current)) break
}
cat("First sentence:", paste(sentence, collapse = " "), "\n")First sentence: The quick brown fox jumps .
A while loop runs forever if its condition never becomes FALSE. Always ensure the loop body modifies the condition variable, and include a maximum iteration counter as a safety exit:
Converged to 0.9698 after 44 iterations.
If you accidentally create an infinite loop, press Escape in the Console, or click the Stop button (red square) in the Console toolbar. RStudio will interrupt the running code. If that fails, use Session → Interrupt R from the menu.
while Loops
Q1. When is a while loop more appropriate than a for loop?
Q2. What is the risk of writing while (TRUE) { ... } without a break statement inside the body?
What you will learn: How to write your own reusable functions — the single most important skill for writing clean, maintainable R code.
Key concepts: Function definition, arguments, default values, return values, scope, documentation
Why it matters: Functions eliminate copy-paste errors, make your intentions explicit, and make code testable and shareable. If you have written the same block of code more than twice, it should be a function.
[1] "Hello from computational linguistics!"
[1] "Hello from corpus linguistics!"
Arguments without a default are required — omitting them raises an error. Arguments with a default are optional and use their default when not supplied:
[1] 0.625
[1] 0.75
A function automatically returns its last evaluated expression. Use return() explicitly for early exits when input validation requires it:
safe_ttr <- function(tokens, lowercase = TRUE) {
if (length(tokens) == 0) {
warning("Empty token vector supplied — returning NA.")
return(NA_real_)
}
if (!is.character(tokens)) {
stop("tokens must be a character vector.")
}
if (lowercase) tokens <- tolower(tokens)
length(unique(tokens)) / length(tokens)
}
safe_ttr(character(0)) # triggers warning, returns NAWarning in safe_ttr(character(0)): Empty token vector supplied — returning NA.
[1] NA
[1] 0.625
Functions can return only one object, but that object can be a named list containing as many results as needed:
corpus_stats <- function(tokens, lowercase = TRUE) {
if (lowercase) tokens <- tolower(tokens)
list(
n_tokens = length(tokens),
n_types = length(unique(tokens)),
ttr = round(length(unique(tokens)) / length(tokens), 3),
longest = tokens[which.max(nchar(tokens))]
)
}
result <- corpus_stats(sample_tokens)
result$ttr[1] 0.625
[1] "the"
List of 4
$ n_tokens: int 8
$ n_types : int 5
$ ttr : num 0.625
$ longest : chr "the"
Variables created inside a function live only inside that function — they are invisible to the global environment and cannot accidentally overwrite your workspace objects:
[1] "hello world"
[1] FALSE
<<- operator
If you need to modify a variable in the calling environment from inside a function (rare), use <<-. This searches up the call stack and modifies the variable there. However, this is considered bad practice in most data analysis code because it creates hidden side effects that make functions unpredictable. Prefer returning a value and assigning it explicitly.
Good functions should be documented so you and colleagues can understand them months later. The conventional format mirrors the roxygen2 package style:
#' Compute Type-Token Ratio
#'
#' @description
#' Calculates the type-token ratio (TTR) of a character vector of tokens.
#' TTR = number of unique word types / total number of tokens.
#'
#' @param tokens A character vector of tokens (words).
#' @param lowercase Logical. If TRUE (default), tokens are lowercased before
#' counting, so "The" and "the" count as the same type.
#'
#' @return A single numeric value between 0 and 1. Values closer to 1
#' indicate higher lexical diversity.
#'
#' @examples
#' ttr(c("the", "cat", "sat", "on", "the", "mat"))
#' ttr(c("The", "Cat", "sat"), lowercase = FALSE)
ttr <- function(tokens, lowercase = TRUE) {
if (length(tokens) == 0) return(NA_real_)
if (lowercase) tokens <- tolower(tokens)
length(unique(tokens)) / length(tokens)
}Here is a realistic example: a family of small, focused functions composed into a pipeline:
normalise_text <- function(text) {
text <- tolower(trimws(text))
gsub("\\s+", " ", text)
}
remove_punct <- function(text) {
gsub("[[:punct:]]", "", text)
}
tokenise <- function(text) {
strsplit(text, "\\s+")[[1]]
}
remove_stopwords <- function(tokens,
stopwords = c("the","a","an","of","in","and","to","is")) {
tokens[!tokens %in% stopwords]
}
clean_and_tokenise <- function(text, stopwords = NULL) {
text <- normalise_text(text)
text <- remove_punct(text)
tokens <- tokenise(text)
if (!is.null(stopwords)) tokens <- remove_stopwords(tokens, stopwords)
tokens
}
# Apply to a single document
example_text <- "The syntactic properties of embedded clauses remain poorly understood."
clean_and_tokenise(example_text,
stopwords = c("the","a","an","of","in","and","to","is"))[1] "syntactic" "properties" "embedded" "clauses" "remain"
[6] "poorly" "understood"
doc_id register n_tokens content_tokens
1 doc1 Academic 11 7
2 doc2 Academic 10 7
3 doc3 Academic 12 7
4 doc4 Academic 11 7
5 doc5 News 14 8
6 doc6 News 16 12
Q1. A function has no explicit return() statement. What does it return?
Q2. You write x <- 99 inside a function body. After calling the function, does x exist in the global environment?
Q3. Your function computes three things: n_tokens, n_types, and TTR. What is the best way to return all three?
apply FamilyWhat you will learn: How to apply a function to every element of a vector or list without writing an explicit loop.
Key functions: sapply(), lapply(), apply()
Why it matters: The apply family is more concise than loops and expresses intent clearly — “apply this function to each element of this object.”
sapply() — simplified applysapply() applies a function to each element of a vector or list and simplifies the result to a vector or matrix if possible:
The syntactic properties of embedded clauses remain poorly understood.
70
Phonological alternations in unstressed syllables exhibit considerable variation.
81
Discourse coherence is maintained through a variety of cohesive devices.
72
The morphological complexity of agglutinative languages poses theoretical challenges.
85
Scientists announced a major breakthrough in renewable energy storage yesterday.
80
Local authorities confirmed that road closures will affect the city centre this weekend.
88
doc1 doc2 doc3
0.8333333 1.0000000 0.6666667
Use an anonymous function for more complex operations:
The syntactic properties of embedded clauses remain poorly understood.
7
Phonological alternations in unstressed syllables exhibit considerable variation.
7
Discourse coherence is maintained through a variety of cohesive devices.
7
The morphological complexity of agglutinative languages poses theoretical challenges.
7
Scientists announced a major breakthrough in renewable energy storage yesterday.
8
Local authorities confirmed that road closures will affect the city centre this weekend.
12
The prime minister addressed parliament amid growing calls for electoral reform.
9
Unemployment figures fell sharply in the third quarter according to new statistics.
8
She had not expected the letter to arrive so soon, or to contain such news.
7
The old house creaked and groaned as the storm gathered strength outside.
7
He said nothing for a long time, watching the rain trace patterns on the glass.
9
By morning the fog had lifted and the valley lay green and still below them.
7
lapply() — list applylapply() always returns a list, making it safer when results have different lengths or types:
apply() — matrix / data frame applyapply() operates on matrices or data frames, applying a function across rows (MARGIN = 1) or columns (MARGIN = 2):
n_tokens content_tokens
13.91667 9.25000
doc1 doc2 doc3 doc4 doc5 doc6
18 17 19 18 22 28
sapply() and lapply()Function | Input | Output | Use when |
|---|---|---|---|
sapply() | vector or list | vector/matrix (simplified) or list if simplification fails | results are all the same type and length |
lapply() | vector or list | always a list | results differ in length or type; you always want a list |
apply() | matrix or data frame | vector or list | you want to summarise across rows or columns of a matrix |
purrrWhat you will learn: How to use purrr::map() and its variants as a modern, consistent alternative to the apply family.
Key functions: map(), map_chr(), map_dbl(), map_df(), map2(), walk()
Why it matters: purrr functions have consistent, predictable behaviour and integrate cleanly with dplyr pipelines.
The purrr package provides a family of map() functions that replace the apply family with a more consistent interface. Every map() function takes a list or vector and applies a function to each element.
map() and type-specific variantsmap() always returns a list. Type-specific variants guarantee a particular output type and fail informatively if the results do not match:
$doc1
[1] 6
$doc2
[1] 9
$doc3
[1] 6
doc1 doc2 doc3
0.8333333 1.0000000 0.6666667
doc1 doc2 doc3
6 9 6
doc1 doc2 doc3
"the cat" "a quick" "to be"
map_df() — map to a data framemap_df() applies a function that returns a data frame to each element and binds the results together:
doc n_tokens n_types ttr
1 doc1 6 5 0.833
2 doc2 9 9 1.000
3 doc3 6 4 0.667
map2() — map over two inputs simultaneouslymap2() applies a function to corresponding elements of two vectors or lists:
text_A : TTR = 1
text_B : TTR = 1
text_C : TTR = 0.75
[1] 1.00 1.00 0.75
walk() — map for side effectswalk() is like map() but used when you want the side effect (printing, writing a file, making a plot) rather than the return value. It invisibly returns the input, enabling piping:
Register: Academic | Docs: 4 | Mean tokens: 11.0
Register: Fiction | Docs: 4 | Mean tokens: 16.5
Register: News | Docs: 4 | Mean tokens: 14.2
apply and purrr
Q1. What is the difference between sapply() and lapply()?
Q2. When would you use purrr::walk() instead of purrr::map()?
What you will learn: How to write code that handles errors and warnings gracefully rather than crashing.
Key functions: tryCatch(), try(), stop(), warning(), message()
Why it matters: When processing many files or documents, a single error should not halt your entire pipeline.
Use stop(), warning(), and message() to communicate problems from inside your functions:
compute_ttr <- function(tokens) {
if (!is.character(tokens)) stop("tokens must be a character vector")
if (length(tokens) == 0) warning("Empty vector — returning NA")
if (length(tokens) < 10) message("Note: TTR is unreliable for short texts")
if (length(tokens) == 0) return(NA_real_)
length(unique(tokens)) / length(tokens)
}
compute_ttr(c("the", "cat", "sat")) # triggers message: short textNote: TTR is unreliable for short texts
[1] 1
The three signals have different effects on execution: stop() halts immediately; warning() signals a problem but continues; message() prints an informational note and continues.
tryCatch() — handle errors gracefullytryCatch() intercepts errors, warnings, and messages, letting you decide what to do instead of crashing:
safe_ttr <- function(tokens) {
tryCatch(
expr = compute_ttr(tokens),
error = function(e) {
cat("Error in compute_ttr:", conditionMessage(e), "\n")
NA_real_
},
warning = function(w) {
cat("Warning:", conditionMessage(w), "\n")
NA_real_
}
)
}
safe_ttr(c("the", "cat", "sat", "on", "the", "mat")) # normalNote: TTR is unreliable for short texts
[1] 0.8333333
Error in compute_ttr: tokens must be a character vector
[1] NA
Warning: Empty vector — returning NA
[1] NA
tryCatch() across a pipelineThis pattern is invaluable when processing many documents — one bad item should not stop the whole run:
Note: TTR is unreliable for short texts
Error in compute_ttr: tokens must be a character vector
Warning: Empty vector — returning NA
Note: TTR is unreliable for short texts
[1] 0.8333333 NA NA 1.0000000
Q1. What is the difference between stop(), warning(), and message() inside a function?
Q2. Why is wrapping a function call in tryCatch() useful when processing a large number of files or documents?
A concise guide to writing better R code: when to loop, when to vectorise, how to name and document functions, and the DRY principle.
Situation | Best tool |
|---|---|
Apply the same operation to every element of a vector | Vectorised operation (e.g. nchar(), tolower(), arithmetic) |
Apply the same operation to each group in a data frame | dplyr::group_by() + summarise() or mutate() |
Apply a function to each element and collect results | sapply() / lapply() / purrr::map() |
Iterate when each step depends on the previous result | for loop with pre-allocated output |
Number of iterations unknown; stop when condition met | while loop (with break safety exit) |
Apply a function for its side effects (print, save, plot) | purrr::walk() or a for loop |
Handle different cases of a single categorical variable | ifelse() / case_when() / switch() |
clean_tokenise_count_and_plot() is a sign it should be four functionsclean_text(), compute_ttr(), plot_frequency(), not myFunc() or data2()stop() at the top of the function body for invalid argumentsNA), and invalid inputs# Good: clear structure, consistent indentation, descriptive names
compute_register_stats <- function(data, group_col = "register") {
data |>
dplyr::group_by(.data[[group_col]]) |>
dplyr::summarise(
n = dplyr::n(),
mean_tok = round(mean(n_tokens), 1),
sd_tok = round(sd(n_tokens), 2),
.groups = "drop"
)
}
# Bad: cryptic names, no whitespace, no structure
f<-function(d,g="register"){d%>%group_by(.data[[g]])%>%summarise(n=n(),m=round(mean(n_tokens),1))}Don’t Repeat Yourself. If you find yourself copy-pasting a block of code and changing one value, that block should be a function parameterised by that value. Code duplication multiplies the places you must update when requirements change and multiplies the opportunities for inconsistency.
# Before: copy-pasted three times with minor changes
academic_ttr <- ...
news_ttr <- ...
fiction_ttr <- ...
# After: one function, called three times
get_register_ttr <- function(data, reg) { ... }
sapply(c("Academic", "News", "Fiction"), get_register_ttr, data = corpus)Martin Schweinberger. 2026. Working with R: Control Flow, Functions, and Programming. The Language Technology and Data Analysis Laboratory (LADAL), The University of Queensland, Australia. url: https://ladal.edu.au/tutorials/workingwithr/workingwithr.html (Version 3.1.1). doi: 10.5281/zenodo.19332999 .
@manual{martinschweinberger2026working,
author = {Martin Schweinberger},
title = {Working with R: Control Flow, Functions, and Programming},
year = {2026},
note = {https://ladal.edu.au/tutorials/workingwithr/workingwithr.html},
organization = {The Language Technology and Data Analysis Laboratory (LADAL), The University of Queensland, Australia},
edition = {2026.03.27}
doi = {}
}
R version 4.4.2 (2024-10-31 ucrt)
Platform: x86_64-w64-mingw32/x64
Running under: Windows 11 x64 (build 26200)
Matrix products: default
locale:
[1] LC_COLLATE=English_United States.utf8
[2] LC_CTYPE=English_United States.utf8
[3] LC_MONETARY=English_United States.utf8
[4] LC_NUMERIC=C
[5] LC_TIME=English_United States.utf8
time zone: Australia/Brisbane
tzcode source: internal
attached base packages:
[1] stats graphics grDevices datasets utils methods base
other attached packages:
[1] purrr_1.0.4 flextable_0.9.7 tidyr_1.3.2 ggplot2_4.0.2
[5] dplyr_1.2.0 checkdown_0.0.13
loaded via a namespace (and not attached):
[1] utf8_1.2.4 generics_0.1.3 fontLiberation_0.1.0
[4] renv_1.1.1 xml2_1.3.6 digest_0.6.39
[7] magrittr_2.0.3 evaluate_1.0.3 grid_4.4.2
[10] RColorBrewer_1.1-3 fastmap_1.2.0 jsonlite_1.9.0
[13] zip_2.3.2 scales_1.4.0 fontBitstreamVera_0.1.1
[16] codetools_0.2-20 textshaping_1.0.0 cli_3.6.4
[19] rlang_1.1.7 fontquiver_0.2.1 litedown_0.9
[22] commonmark_2.0.0 withr_3.0.2 yaml_2.3.10
[25] gdtools_0.4.1 tools_4.4.2 officer_0.6.7
[28] uuid_1.2-1 vctrs_0.7.1 R6_2.6.1
[31] lifecycle_1.0.5 htmlwidgets_1.6.4 ragg_1.3.3
[34] pkgconfig_2.0.3 pillar_1.10.1 gtable_0.3.6
[37] data.table_1.17.0 glue_1.8.0 Rcpp_1.0.14
[40] systemfonts_1.2.1 xfun_0.56 tibble_3.2.1
[43] tidyselect_1.2.1 rstudioapi_0.17.1 knitr_1.51
[46] farver_2.1.2 htmltools_0.5.9 rmarkdown_2.30
[49] compiler_4.4.2 S7_0.2.1 askpass_1.2.1
[52] markdown_2.0 openssl_2.3.2
This tutorial was re-developed with the assistance of Claude (claude.ai), a large language model created by Anthropic. Claude was used to help revise the tutorial text, structure the instructional content, generate the R code examples, and write the checkdown quiz questions and feedback strings. All content was reviewed, edited, and approved by the author (Martin Schweinberger), who takes full responsibility for the accuracy and pedagogical appropriateness of the material. The use of AI assistance is disclosed here in the interest of transparency and in accordance with emerging best practices for AI-assisted academic content creation.